iT邦幫忙

2026 iThome 鐵人賽

0
Software Development

Kotlin Lambda 從零開始系列 第 31

Kotlin Lambda 從零開始 Day 31:Sequence 效能分析與使用時機總結

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260807/201219480fj7uFk1cv.jpg

這篇文章會比較 Collection 和 Sequence 的效能差異,整理出什麼時候該用哪個,並做 Sequence 篇的總回顧

Kotlin ↔ C# 對照表

Kotlin C# 備註
list.filter { }.map { } list.Where().Select().ToList() Kotlin 預設 Eager,C# LINQ 預設 Lazy
list.asSequence().filter { } list.Where() Kotlin 要手動切 Lazy,C# 本來就是 Lazy
seq.take(5).toList() seq.Take(5).ToList() 兩邊都是只走必要元素就停
JMH @Benchmark BenchmarkDotNet [Benchmark] 各自生態的 microbenchmark 工具

Kotlin 跟 C# 的差別在 API 預設值。C# LINQ 的轉換運算子通常延遲執行,Kotlin 的 Collection 操作預設 Eager,想建立 Lazy 管線得呼叫 asSequence()。終端操作在兩邊都會觸發求值

Collection vs Sequence — 效能差在哪

小集合:沒有固定贏家

// 100 個元素
val small = (1..100).toList()

// Collection 版
small.filter { it % 2 == 0 }.map { it * 10 }.first()

// Sequence 版
small.asSequence().filter { it % 2 == 0 }.map { it * 10 }.first()

這段最後呼叫 first(),兩個版本做的工作不一樣。Collection 版會先完成整個 filtermap;Sequence 版找到第一個偶數後就停止。因此即使只有 100 個元素,Sequence 仍可能因短路而占優勢

如果兩邊都完整產出結果,例如最後呼叫 toList(),兩邊各有各的成本。Collection 版走 inline 與直接迴圈,但每個步驟都會配置一個中間 List;Sequence 版要建立包裝物件並透過 Iterator 鏈呼叫,不過只在終端操作配置一次結果。這些成本會受到 JDK、Kotlin 版本、JIT 與管線內容影響,不能只用「小集合」推導勝負,本篇後面的 benchmark 就跑出了跟直覺相反的結果

這也是我重跑 benchmark 前會先檢查的事:兩邊的輸入、轉換步驟和終端操作是否真的一致

大集合 + 提前終止:Sequence 能少做許多工作

// 1,000,000 個元素,只需要前 5 個結果
val large = (1..1_000_000).toList()

// Collection 版:filter 走完 100 萬個,map 走完 filter 結果,最後 take 5 個
large.filter { it % 7 == 0 }.map { it.toLong() * it }.take(5)

// Sequence 版:找到 5 個就停
large.asSequence().filter { it % 7 == 0 }.map { it.toLong() * it }.take(5).toList()

Collection 版必須把 100 萬個元素全部 filter 一遍,再全部 map 一遍,最後才 take 前 5 個。產出兩個大型中間 List

Sequence 版從頭開始走,碰到 7 的倍數就 map 一下,收集到 5 個就停。可能只需要走 35 個元素(7, 14, 21, 28, 35)就搞定了

只取部分結果:Sequence 可以短路

// 找到第一個符合條件的
employees.asSequence()
    .filter { it.salary > 70000 }
    .map { it.name }
    .first()

day 27 的測試驗證了該組資料中 Eager 版處理 11 次,Lazy 版只處理 2 次。這說明短路能減少運算次數,但實際耗時仍要量測

用 JMH 量化效能差異

前面只能算程式碼層面的推測。要知道自己的服務會不會變快,我還是會用 JMH(Java Microbenchmark Harness)量測

這個系列的主專案是 Day 01 建立的 Kotlin Toolchain 專案,裡面沒有 Gradle 設定。JMH 需要 Gradle plugin,因此範例專案另外放一個 benchmark/ Gradle 子專案,不要把下面的設定加到根目錄

想簡單一點的話,不用自己從零刻設定檔,直接用 IntelliJ IDEA 的 New Project 建一個一般的 Kotlin 專案就好。Language 選 Kotlin、Build system 選 Gradle、Gradle DSL 選 Kotlin,JDK 挑一個 21 以上的版本。IDEA 會把 build.gradle.ktssettings.gradle.kts 和 Gradle Wrapper 都準備好,剩下的只有兩件事:在 build.gradle.kts 補上 JMH plugin,以及把 benchmark 原始碼放進 src/jmh/kotlin/

專案結構大概就長這樣

benchmark/
├── build.gradle.kts
├── settings.gradle.kts
├── gradlew
├── gradle/wrapper/
└── src/jmh/kotlin/FilterMapBenchmark.kt

settings.gradle.kts 只需要設定專案名稱,IDEA 產生的內容可以直接留著

rootProject.name = "kotlin-lambda-benchmark"

build.gradle.kts 使用 Kotlin 2.4.0 與 JMH plugin 0.7.3,IDEA 產生的 groupversiondependencies 等區塊保留即可

plugins {
    kotlin("jvm") version "2.4.0"
    id("me.champeau.jmh") version "0.7.3"
}

repositories {
    mavenCentral()
}

jmh {
    warmupIterations.set(5)
    iterations.set(10)
    fork.set(1)
}

這裡有個容易踩到的地方:benchmark 原始碼一定要放在 src/jmh/kotlin/,不是 IDEA 預設幫你開的 src/main/kotlin/。JMH plugin 只把 jmh-core 掛到 jmh 這個 source set,檔案放到 src/main/kotlin/ 的話,org.openjdk.jmh.annotations 底下的 import 會全部變成 Unresolved reference,編譯直接失敗

JMH 也不接受放在 default package 的 benchmark class,所以原始碼要先宣告 package

package benchmark

import java.util.concurrent.TimeUnit
import org.openjdk.jmh.annotations.Benchmark
import org.openjdk.jmh.annotations.BenchmarkMode
import org.openjdk.jmh.annotations.Fork
import org.openjdk.jmh.annotations.Measurement
import org.openjdk.jmh.annotations.Mode
import org.openjdk.jmh.annotations.OutputTimeUnit
import org.openjdk.jmh.annotations.Scope
import org.openjdk.jmh.annotations.State
import org.openjdk.jmh.annotations.Warmup

@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 5)
@Measurement(iterations = 10)
@Fork(1)
open class FilterMapBenchmark {
    val small = (1..100).toList()
    val medium = (1..10_000).toList()
    val large = (1..1_000_000).toList()

    @Benchmark
    fun smallCollection(): List<Int> =
        small.filter { it % 2 == 0 }.map { it * 10 }

    @Benchmark
    fun smallSequence(): List<Int> =
        small.asSequence().filter { it % 2 == 0 }.map { it * 10 }.toList()

    @Benchmark
    fun mediumCollection(): List<Int> =
        medium.filter { it % 2 == 0 }.map { it * 10 }

    @Benchmark
    fun mediumSequence(): List<Int> =
        medium.asSequence().filter { it % 2 == 0 }.map { it * 10 }.toList()

    @Benchmark
    fun largePartialCollection(): List<Long> =
        large.filter { it % 7 == 0 }.map { it.toLong() * it }.take(5)

    @Benchmark
    fun largePartialSequence(): List<Long> =
        large.asSequence().filter { it % 7 == 0 }.map { it.toLong() * it }.take(5).toList()
}

Kotlin class 預設是 final,但 JMH 的 bytecode generator 需要繼承 benchmark class 來產生測試程式,因此這裡必須使用 open class。只跑 jmhClasses 仍可能看起來正常;./gradlew jmhJar 會真正執行 bytecode generator,可以把這類問題擋下來

❯ ./gradlew jmhJar

BUILD SUCCESSFUL in 387ms
5 actionable tasks: 5 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.6.0/userguide/configuration_cache_enabling.html

看到 BUILD SUCCESSFUL 就表示 source set、package 宣告和 open class 這三件事都對了,可以往下跑

跑之前可以先寫下自己的假設:完整物化時,Collection 可能受益於 inline 與較直接的迴圈;只取前幾筆時,Sequence 可以少走大量元素。等一下看結果會發現,其中一個假設沒有成立

進入 benchmark/ 後執行 ./gradlew jmh。這組設定跑完大約 15 分鐘,最後會印出這樣的摘要

Benchmark                                  Mode  Cnt     Score     Error  Units
FilterMapBenchmark.largePartialCollection  avgt   10  2616.002 ± 101.703  us/op
FilterMapBenchmark.largePartialSequence    avgt   10     0.088 ±   0.006  us/op
FilterMapBenchmark.mediumCollection        avgt   10    47.921 ±   3.844  us/op
FilterMapBenchmark.mediumSequence          avgt   10    55.268 ±   9.469  us/op
FilterMapBenchmark.smallCollection         avgt   10     0.397 ±   0.013  us/op
FilterMapBenchmark.smallSequence           avgt   10     0.309 ±   0.004  us/op

Score 是每次操作的平均耗時,Error 是誤差範圍,數字越小越快。這組數字的執行環境是 Apple M2 Max、macOS 26.5.2、JDK 21.0.11(Temurin)、Kotlin 2.4.0、JMH 1.36,fork 1 次、warmup 5 次、measurement 10 次,每次 10 秒。換機器、換 JDK 版本數字就會不一樣,所以下面談的是差距的量級,不是絕對值。你自己跑的時候,這些環境資訊也要一起記下來,不然過幾個月回頭看會不知道這組數字是什麼條件下量的

三組分開看

大集合只取前 5 筆:2616 us/op 對 0.088 us/op,差了將近三萬倍。Collection 版把 100 萬個元素完整 filter 一遍再 map 一遍,Sequence 版走到第 35 個就收工。這種量級的差距不是常數項的優化能補回來的,前面推測 Sequence 能少做很多工作,這裡得到了驗證

中集合完整物化:Collection 是 47.9 us/op、Sequence 是 55.3 us/op,Collection 快了大約 15%。但把誤差算進去,兩邊的區間其實有重疊(44.1 ~ 51.8 對 45.8 ~ 64.7),這種程度的差異我不會拿來當選型依據

小集合完整物化:0.397 us/op 對 0.309 us/op,Sequence 反而比較快,跟前面「Collection 受益於 inline」的假設相反。原因在配置量:Collection 版的 filter 產生一個中間 ArrayListmap 再產生一個,Sequence 版則只在 toList() 配置最後那一個。100 個元素的規模下,inline 省下來的呼叫成本補不回多配置一個 List 的代價

結果跟假設相反的時候,先檢查 benchmark 是否公平:兩邊的輸入、轉換步驟和終端操作有沒有真的一致。確認公平之後,再回頭看 JIT、配置量與資料分布。這次就是配置量的差異蓋過了 inline 的優勢

時間之外,記憶體也要量。Collection 管線會為每個轉換步驟建立中間 List,Sequence 則建立包裝物件並在終端操作建立最終結果。加掛 JMH 的 GC profiler 就能看到實際配置量,直接對 jmhJar 產生的 jar 下 -prof gc

❯ java -jar build/libs/benchmark-1.0-SNAPSHOT-jmh.jar -prof gc -f 1 -wi 5 -i 10

Benchmark                                                      Mode  Cnt        Score  Units
FilterMapBenchmark.largePartialCollection:·gc.alloc.rate.norm  avgt   10  5921344.104  B/op
FilterMapBenchmark.largePartialSequence:·gc.alloc.rate.norm    avgt   10      176.000  B/op
FilterMapBenchmark.mediumCollection:·gc.alloc.rate.norm        avgt   10   175184.002  B/op
FilterMapBenchmark.mediumSequence:·gc.alloc.rate.norm          avgt   10   155272.002  B/op
FilterMapBenchmark.smallCollection:·gc.alloc.rate.norm         avgt   10     1864.000  B/op
FilterMapBenchmark.smallSequence:·gc.alloc.rate.norm           avgt   10     1680.000  B/op

想走 Gradle 的話,在 build.gradle.ktsjmh 區塊加一行 profilers.set(listOf("gc"))./gradlew jmh 就會自動帶上

gc.alloc.rate.norm 是每次操作配置的位元組數。要注意這是另一次執行,而且 profiler 本身有 overhead,所以這份輸出的耗時跟上一份不會完全一樣,這裡只看配置量

大集合那組是 5.9 MB/op 對 176 B/op。Collection 版產生兩個各裝十幾萬個元素的中間 List,Sequence 版只配置最後那五筆的容器,跟耗時的差距是同一個量級

小集合那組 1864 B/op 對 1680 B/op,Sequence 少了大約 10%,跟它耗時較短的方向一致,前面「配置量蓋過 inline 優勢」的說法在這裡有數字撐著

中集合那組比較有意思:Sequence 配置 155 KB、Collection 配置 175 KB,Sequence 少了大約 11%,但時間上反而是 Sequence 慢一些。配置量少不等於跑得快,Iterator 鏈的呼叫成本在這個規模開始吃掉配置量省下來的部分。這也是為什麼不要只看單一指標就決定用哪一個

兩份數字合起來看

把耗時和配置量放在一起,三組情境的全貌是這樣,每格都是「Collection vs Sequence」

情境 耗時(us/op) 配置量(B/op) 結果
小集合完整物化 0.397 vs 0.309 1,864 vs 1,680 Sequence 快 22%、少配置 10%
中集合完整物化 47.9 vs 55.3 175,184 vs 155,272 Collection 略快(誤差重疊)、Sequence 少配置 11%
大集合取前 5 筆 2616 vs 0.088 5,921,344 vs 176 Sequence 兩項都好三萬倍

這張表講完了三件事

第一,真正的分水嶺是能不能短路,不是資料量。三組裡唯一出現數量級差距的是能短路的那組;兩組完整物化的情境,差距都在同一個數量級內,甚至互有輸贏。管線末端是 firsttakeany 的時候,Sequence 帶來的是質變;末端是 toList 的時候,換不換只是零點幾倍的差別

第二,資料量本身沒有決定性。100 個元素這組 Sequence 贏,10,000 個元素這組反而 Collection 贏。如果照「資料量大才用 Sequence」這種說法去判斷,這兩組都會判斷錯

第三,耗時和配置量不一定同向。中集合那組 Sequence 少配置 11%,時間卻沒有跟著變快,反而是略慢的那一邊。只挑一個指標看,很容易得到跟另一個指標相反的結論

所以在完整物化的情境,我不會為了效能去把 Collection 改寫成 Sequence,差異小到不值得,哪一版讀起來清楚就用哪一版。真正值得換的是管線能短路、或者來源根本是無限的時候,那才是 Sequence 的主場

什麼時候用 Sequence?

我實務上的判斷順序不是資料筆數,而是結果要怎麼用

  • 來源可能無限,或終端操作能短路(firsttakeany):優先考慮 Sequence
  • 多個轉換步驟會建立大型中間集合:Sequence 可能降低配置量
  • 結果需要隨機存取、重複使用或完整物化:Collection 通常更直接
  • 只有一個簡單操作:先用 Collection,除非需要延遲求值
  • 效能敏感路徑:用代表性的資料與終端操作跑 benchmark

決策流程

需要無限序列或提前停止?
  ├── 是 → 優先評估 Sequence
  └── 否 → 是否要避免多個大型中間集合?
              ├── 是 → 比較 Sequence 與 Collection 的配置和耗時
              └── 否 → 直接使用 Collection

一般的集合處理,我會先用比較直接的 Collection。看到 firsttakeany 這類可短路的終端操作,或管線會建立幾個大型中間 List,才把 Sequence 納入選項。若這段程式碼真的在效能敏感路徑,再讓 benchmark 決定

Kotlin Sequence vs C# LINQ vs Java Stream

面向 Kotlin Sequence C# LINQ Java Stream
預設行為 Lazy Lazy Lazy
可重複遍歷 通常可以,部分實作限一次 取決於來源 不行(一次性)
平行處理 無內建 PLINQ parallelStream()
建立方式 asSequence() 原生 stream()
Collection 操作 Eager(預設) Lazy(預設) 沒有對應

Java Stream 用完就不能再用,要遍歷兩次得建立兩個 Stream。Kotlin Sequence 通常可以重複遍歷,但官方 API 也有一次性實作;C# LINQ 是否能安全重複列舉則取決於來源與查詢內容

Kotlin Collection 的 filtermap 預設 Eager,延遲處理時要明確切到 Sequence。C# LINQ 的轉換運算子本來就採延遲執行;Java List 沒有對應的 mapfilter 鏈式 API,這類操作通常從 stream() 開始。三邊語法相似,但切換點不同

Kotlin Sequence 沒有內建的平行處理 API。Coroutine 也不會自動讓 Sequence 平行化;需要自行切分工作、選擇 dispatcher 並處理結果順序。C# 有 PLINQ(AsParallel()),Java 有 parallelStream()

第八部分回顧

五篇走過的路線

主題 學到什麼
27 Eager vs Lazy 中間集合的浪費問題,Sequence 的逐元素處理
28 基礎設施 sequenceOfasSequencegenerateSequence 的實作
29 filter / map 包裝 Sequence 模式,為什麼不用 inline
30 中間 vs 終端 take 是中間操作,first/toList 是終端操作
31 效能與選擇 依管線、終端操作與實測結果選擇

從 day 05 到 day 31,我們手刻了具代表性的 Eager Collection 與 Lazy Sequence 操作。Eager 版多以 inlinefor 迴圈和 ArrayList 實作;Lazy 版則用包裝 Sequence 與 Iterator 的拉取模式

本部分 API 速查

第八部分手刻過的 Sequence 函式整理如下

函式 篇號 用途
mySequenceOf day 28 把幾個元素包成一個 Sequence
myEmptySequence day 28 產生一個空的 Sequence
myAsSequence day 28 把現有 Iterable 轉成 Lazy 的 Sequence
myGenerateSequence day 28 用 seed 和規則產生(可無限)序列
myFilter(Sequence 版) day 29 中間操作,逐元素篩選、回傳新 Sequence
myMap(Sequence 版) day 29 中間操作,逐元素轉換、回傳新 Sequence
myTake day 30 中間操作,只取前 n 個就停
myFirst day 30 終端操作,取第一個並結束遍歷
myToList day 30 終端操作,把整個 Sequence 收進一個 List

小結

Collection 和 Sequence 的差異不只在資料量。是否能短路、是否建立中間集合、是否完整物化,以及結果會不會重複使用,都會影響選擇。一般程式先選語意清楚的寫法;效能敏感路徑再用同條件 benchmark 驗證

下一篇(day 32)進入進階語法篇,看 Scope Functions 怎麼跟 Collection 操作搭配

參考資料


Yes


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Lambda 從零開始 Day 30:Sequence 的中間操作 vs 終端操作
下一篇
Kotlin Lambda 從零開始 Day 32:Scope Functions 與 Collection 的組合技
系列文
Kotlin Lambda 從零開始35
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言